Implement schema composition across multiple kinds (plan 156) - #288
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files
☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Implements plan 156: when a file resolves to multiple kinds that each declare a required-structure schema, the schemas now compose rather than the last layer winning. The merge layer accumulates each source into a schema-sources list, and the rule loads and composes them at check time via a new internal/schema Compose API. Documentation, the four directive READMEs, and directive-proto.md are updated to exploit composition (directive-rule-readme now declares only the Pattern requirement; rule-readme contributes the rest).
Changes:
- New
internal/schema/Composethat unions frontmatter (CUE&for shared keys), merges sections by literal heading text, OR-sclosed:, and errors on conflictingrequire.filename/index.output. - Refactored
requiredstructure.Rulearound an orderedSources []SchemaSource; merge layer translates legacyschema:/inline-schema:into appendedschema-sourcesentries;checkcomposes file + inline sources at runtime, retaining single-source legacy paths for body/heading sync. - Reassigned the four directive READMEs to both
rule-readmeanddirective-rule-readme, simplifieddirective-proto.md, and moved the Meta-Information sections before Pattern in MDS019/021/038/039.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| plan/156_kind-schema-composition.md | Marks plan tasks/criteria done and rewrites them to describe what shipped. |
| PLAN.md | Flips plan 156 status to ✅. |
| internal/schema/compose.go | New Compose function and helpers (sections/frontmatter/index/acronyms/cross-refs). |
| internal/schema/compose_test.go | Unit tests covering composition rules and acceptance criteria. |
| internal/rules/requiredstructure/rule.go | Adds Sources/SchemaSource, multi-source Check, legacy single-source compatibility. |
| internal/rules/requiredstructure/compose_test.go | End-to-end Check tests for composed multi-kind sources. |
| internal/config/merge.go | New translateSchemaSource flow; removes clearSchemaState/"last source wins". |
| internal/config/provenance.go | Mirrors the schema-source translation in provenance/explain layers. |
| internal/config/schema_kinds_test.go | Updated to assert schema-sources accumulation rather than clearing. |
| internal/config/kinds_test.go | Provenance assertions updated for translated schema-sources entries. |
| internal/engine/kinds_test.go | Asserts kind-level schema: reaches the rule via schema-sources. |
| internal/rules/directive-proto.md | Trimmed to declare only Pattern + nature: directive. |
| internal/rules/MDS019/021/038/039 READMEs | Meta-Information moved above Pattern to match composed section order. |
| internal/rules/MDS020-required-structure/README.md | Notes multi-kind composition and links to schemas guide. |
| docs/guides/schemas.md | New “Composition across kinds” section with worked example. |
| docs/development/architecture/cross-system.md | New schema-composition contract block. |
| .mdsmith.yml | Reassigns directive READMEs to both kinds; removes the prior workaround. |
Copilot review caught that Fix only read r.InlineSchema and silently no-op'd for multi-source configs (where reflectSingleSource leaves InlineSchema nil). checkComposedSources still validated the composed schema's Index block, so users would see a "missing index" diagnostic that Fix couldn't resolve. Fix now composes the same way Check does (via the new composedSchemaForFix helper) and writes the index when the composed schema declares one. Added a regression test that exercises a two-source config where only one source carries the index block.
Copilot review on PR #288 surfaced two real composition bugs: 1. compose.go silently picked RootLevel from the first non-nil input. Mixing an inline schema (RootLevel=2, H1 owned by the title) with a file-based proto.md that wraps its sections in an H1 wildcard (RootLevel=1) made the validator's section walk start at the wrong depth for one input. Now Compose errors when inputs disagree on the effective root level so the misconfiguration surfaces as a config error instead of silently mis-validated headings. 2. composeAcronyms unioned Scope across inputs, but Acronyms. Scope semantics are "empty = document-wide; non-empty = restricted". Unioning silently narrowed a document-wide check to the other input's restricted list. Now once any input declares Acronyms with no Scope restriction the composed Scope becomes nil (document-wide) — restricted inputs only union with each other. Updated TestComposedSchemaForFix_FileSource to use a proto.md that roots at H2 so the file source agrees with the inline default of 2; added compose_test cases for the new RootLevel mismatch error and for the document-wide-wins acronym path (both orders + both restricted). Also extended the CI debug capture (added in 685b228) to post the mdsmith fix diff as a PR comment, so the contents of the stale catalog are visible without authenticated access to the workflow logs.
CI debug: mdsmith fix diff on this commitdiff --git a/.claude/skills/markdown-audit/SKILL.md b/.claude/skills/markdown-audit/SKILL.md
index 5d201a7..cb90bec 100644
--- a/.claude/skills/markdown-audit/SKILL.md
+++ b/.claude/skills/markdown-audit/SKILL.md
@@ -129,12 +129,10 @@ where: 'nature: "directive"'
sort: id
row: "- `internal/rules/{id}-{name}/pattern/` ({name})"
?>
-
- `internal/rules/MDS019-catalog/pattern/` (catalog)
- `internal/rules/MDS021-include/pattern/` (include)
- `internal/rules/MDS038-toc/pattern/` (toc)
- `internal/rules/MDS039-build/pattern/` (build)
-
<?/catalog?>
Do not paraphrase directive syntax from memory.
diff --git a/PLAN.md b/PLAN.md
index 35d22c3..128b475 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -85,6 +85,6 @@ footer: |
| 154 | ✅ | sonnet | [arch-fix: extract cross-rule helpers](plan/154_arch-fix-rule-helper-extraction.md) |
| 155 | ✅ | sonnet | [arch-fix: relocate convention types out of markdownflavor](plan/155_arch-fix-convention-config-ownership.md) |
| 156 | ✅ | opus | [Composable required-structure schemas across multiple kinds](plan/156_kind-schema-composition.md) |
-| 157 | 🔳 | sonnet | [Catalog filter by front matter property](plan/157_catalog-where-filter.md) |
| 156 | 🔲 | opus | [Section schema — unify entry shape under `heading:` discriminator](plan/156_schema-entry-unification.md) |
+| 157 | 🔳 | sonnet | [Catalog filter by front matter property](plan/157_catalog-where-filter.md) |
<?/catalog?>
diff --git a/editors/claude-code-audit/skills/markdown-audit/SKILL.md b/editors/claude-code-audit/skills/markdown-audit/SKILL.md
index 35636e6..e0013eb 100644
--- a/editors/claude-code-audit/skills/markdown-audit/SKILL.md
+++ b/editors/claude-code-audit/skills/markdown-audit/SKILL.md
@@ -134,12 +134,10 @@ where: 'nature: "directive"'
sort: id
row: "- `internal/rules/{id}-{name}/pattern/` ({name})"
?>
-
- `internal/rules/MDS019-catalog/pattern/` (catalog)
- `internal/rules/MDS021-include/pattern/` (include)
- `internal/rules/MDS038-toc/pattern/` (toc)
- `internal/rules/MDS039-build/pattern/` (build)
-
<?/catalog?>
Do not paraphrase directive syntax from memory.
diff --git a/internal/rules/index.md b/internal/rules/index.md
index 47d1dc6..883f631 100644
--- a/internal/rules/index.md
+++ b/internal/rules/index.md
@@ -91,60 +91,10 @@ header: |
|------|------|-------------|
row: "| [{id}]({filename}) | `{name}` | {description} |"
?>
-| Rule | Name | Description |
-|---------------------------------------------------------------|--------------------------------------|-------------------------------------------------------------------------------------------------------------------------------|
-| [MDS001](MDS001-line-length/README.md) | `line-length` | Line exceeds maximum length. |
-| [MDS002](MDS002-heading-style/README.md) | `heading-style` | Heading style must be consistent. |
-| [MDS003](MDS003-heading-increment/README.md) | `heading-increment` | Heading levels should increment by one. No jumping from `#` to `###`. |
-| [MDS004](MDS004-first-line-heading/README.md) | `first-line-heading` | First line of the file should be a heading. |
-| [MDS005](MDS005-no-duplicate-headings/README.md) | `no-duplicate-headings` | No two headings should have the same text. |
-| [MDS006](MDS006-no-trailing-spaces/README.md) | `no-trailing-spaces` | No trailing whitespace at the end of lines. |
-| [MDS007](MDS007-no-hard-tabs/README.md) | `no-hard-tabs` | No tab characters. Use spaces instead. |
-| [MDS008](MDS008-no-multiple-blanks/README.md) | `no-multiple-blanks` | No more than one consecutive blank line. |
-| [MDS009](MDS009-single-trailing-newline/README.md) | `single-trailing-newline` | File must end with exactly one newline character. |
-| [MDS010](MDS010-fenced-code-style/README.md) | `fenced-code-style` | Fenced code blocks must use a consistent delimiter. |
-| [MDS011](MDS011-fenced-code-language/README.md) | `fenced-code-language` | Fenced code blocks must specify a language. |
-| [MDS012](MDS012-no-bare-urls/README.md) | `no-bare-urls` | URLs must be wrapped in angle brackets or as a link, not left bare. |
-| [MDS013](MDS013-blank-line-around-headings/README.md) | `blank-line-around-headings` | Headings must have a blank line before and after. |
-| [MDS014](MDS014-blank-line-around-lists/README.md) | `blank-line-around-lists` | Lists must have a blank line before and after. |
-| [MDS015](MDS015-blank-line-around-fenced-code/README.md) | `blank-line-around-fenced-code` | Fenced code blocks must have a blank line before and after. |
-| [MDS016](MDS016-list-indent/README.md) | `list-indent` | List items must use consistent indentation. |
-| [MDS017](MDS017-no-trailing-punctuation-in-heading/README.md) | `no-trailing-punctuation-in-heading` | Headings should not end with punctuation. |
-| [MDS018](MDS018-no-emphasis-as-heading/README.md) | `no-emphasis-as-heading` | Don't use bold or emphasis on a standalone line as a heading substitute. |
-| [MDS019](MDS019-catalog/README.md) | `catalog` | Catalog content must reflect selected front matter fields from files matching its glob. |
-| [MDS020](MDS020-required-structure/README.md) | `required-structure` | Document structure and front matter must match its schema. |
-| [MDS021](MDS021-include/README.md) | `include` | Include section content must match the referenced file. |
-| [MDS022](MDS022-max-file-length/README.md) | `max-file-length` | File must not exceed maximum number of lines. |
-| [MDS023](MDS023-paragraph-readability/README.md) | `paragraph-readability` | Paragraph readability index must not exceed a threshold. |
-| [MDS024](MDS024-paragraph-structure/README.md) | `paragraph-structure` | Paragraphs must not exceed sentence and word limits. |
-| [MDS025](MDS025-table-format/README.md) | `table-format` | Tables must have consistent column widths and padding. |
-| [MDS026](MDS026-table-readability/README.md) | `table-readability` | Tables must stay within readability complexity limits. |
-| [MDS027](MDS027-cross-file-reference-integrity/README.md) | `cross-file-reference-integrity` | Links to local files and heading anchors must resolve. |
-| [MDS028](MDS028-token-budget/README.md) | `token-budget` | File must not exceed a token budget. |
-| [MDS029](MDS029-conciseness-scoring/README.md) | `conciseness-scoring` | Paragraph conciseness score must not fall below a threshold. |
-| [MDS030](MDS030-empty-section-body/README.md) | `empty-section-body` | Section headings must include meaningful body content. |
-| [MDS031](MDS031-unclosed-code-block/README.md) | `unclosed-code-block` | Fenced code blocks must have a closing fence delimiter. |
-| [MDS032](MDS032-no-empty-alt-text/README.md) | `no-empty-alt-text` | Images must have non-empty alt text for accessibility. |
-| [MDS033](MDS033-directory-structure/README.md) | `directory-structure` | Markdown files must exist only in explicitly allowed directories. |
-| [MDS034](MDS034-markdown-flavor/README.md) | `markdown-flavor` | Flags Markdown syntax that the declared target flavor does not render. |
-| [MDS035](MDS035-toc-directive/README.md) | `toc-directive` | Flag renderer-specific TOC directives that render as literal text on CommonMark and goldmark. |
-| [MDS036](MDS036-max-section-length/README.md) | `max-section-length` | Section length must not exceed per-level or per-heading limits. |
-| [MDS037](MDS037-duplicated-content/README.md) | `duplicated-content` | Paragraphs should not repeat verbatim across Markdown files. |
-| [MDS038](MDS038-toc/README.md) | `toc` | Keep toc generated heading lists in sync with document headings. |
-| [MDS039](MDS039-build/README.md) | `build` | Validate `<?build?>` directive parameters and keep the section body in sync with the recipe's rendered `body-template`. |
-| [MDS040](MDS040-recipe-safety/README.md) | `recipe-safety` | Validate each build.recipes command for shell-safety at lint time; the rule never executes any binary. |
-| [MDS041](MDS041-no-inline-html/README.md) | `no-inline-html` | Raw HTML tags in Markdown are not allowed; use a Markdown construct or an mdsmith directive instead. |
-| [MDS042](MDS042-emphasis-style/README.md) | `emphasis-style` | Enforces a single delimiter character for bold and italic emphasis, and optionally forbids cross-delimiter nesting. |
-| [MDS043](MDS043-no-reference-style/README.md) | `no-reference-style` | Reference-style links and footnotes require global definition resolution; flag them in favor of inline links. |
-| [MDS044](MDS044-horizontal-rule-style/README.md) | `horizontal-rule-style` | Thematic breaks must use a consistent delimiter style, exact length, and blank-line spacing. |
-| [MDS045](MDS045-list-marker-style/README.md) | `list-marker-style` | Unordered list items must use the configured bullet marker character. |
-| [MDS046](MDS046-ordered-list-numbering/README.md) | `ordered-list-numbering` | Ordered list items must be numbered in the configured style. |
-| [MDS047](MDS047-ambiguous-emphasis/README.md) | `ambiguous-emphasis` | Forbid emphasis sequences whose meaning a human cannot predict at a glance. |
-| [MDS048](MDS048-git-hook-sync/README.md) | `git-hook-sync` | Git artifacts must match the canonical glob-based template derived from .mdsmith.yml. |
-| [MDS049](MDS049-no-space-in-link-text/README.md) | `no-space-in-link-text` | Link text and image alt text must not have leading or trailing whitespace inside the brackets. |
-| [MDS050](MDS050-proper-names/README.md) | `proper-names` | Configured proper names (e.g. JavaScript, GitHub) must appear with their canonical casing. |
-| [MDS051](MDS051-single-h1/README.md) | `single-h1` | At most one H1 heading is allowed per file. |
-| [MDS052](MDS052-no-space-in-code-spans/README.md) | `no-space-in-code-spans` | Inline code spans with leading or trailing whitespace inside the backticks are almost always typos; flag them. |
-| [MDS053](MDS053-no-unused-link-definitions/README.md) | `no-unused-link-definitions` | Every `[label]: url` definition must be consumed by at least one reference-style link or image; duplicate labels are flagged. |
-| [MDS054](MDS054-no-undefined-reference-labels/README.md) | `no-undefined-reference-labels` | Reference-style links and images must have a matching link reference definition in the same file. |
+| Rule | Name | Description |
+|------------------------------------|-----------|-------------------------------------------------------------------------------------------------------------------------|
+| [MDS019](MDS019-catalog/README.md) | `catalog` | Catalog content must reflect selected front matter fields from files matching its glob. |
+| [MDS021](MDS021-include/README.md) | `include` | Include section content must match the referenced file. |
+| [MDS038](MDS038-toc/README.md) | `toc` | Keep toc generated heading lists in sync with document headings. |
+| [MDS039](MDS039-build/README.md) | `build` | Validate `<?build?>` directive parameters and keep the section body in sync with the recipe's rendered `body-template`. |
<?/catalog?>
|
Copilot review caught that Fix only read r.InlineSchema and silently no-op'd for multi-source configs (where reflectSingleSource leaves InlineSchema nil). checkComposedSources still validated the composed schema's Index block, so users would see a "missing index" diagnostic that Fix couldn't resolve. Fix now composes the same way Check does (via the new composedSchemaForFix helper) and writes the index when the composed schema declares one. Added a regression test that exercises a two-source config where only one source carries the index block.
Copilot review on PR #288 surfaced two real composition bugs: 1. compose.go silently picked RootLevel from the first non-nil input. Mixing an inline schema (RootLevel=2, H1 owned by the title) with a file-based proto.md that wraps its sections in an H1 wildcard (RootLevel=1) made the validator's section walk start at the wrong depth for one input. Now Compose errors when inputs disagree on the effective root level so the misconfiguration surfaces as a config error instead of silently mis-validated headings. 2. composeAcronyms unioned Scope across inputs, but Acronyms. Scope semantics are "empty = document-wide; non-empty = restricted". Unioning silently narrowed a document-wide check to the other input's restricted list. Now once any input declares Acronyms with no Scope restriction the composed Scope becomes nil (document-wide) — restricted inputs only union with each other. Updated TestComposedSchemaForFix_FileSource to use a proto.md that roots at H2 so the file source agrees with the inline default of 2; added compose_test cases for the new RootLevel mismatch error and for the document-wide-wins acronym path (both orders + both restricted). Also extended the CI debug capture (added in 685b228) to post the mdsmith fix diff as a PR comment, so the contents of the stale catalog are visible without authenticated access to the workflow logs.
The mdsmith check failed in CI because the PR's merge ref included main's stale `nature:directive` catalog body in internal/rules/index.md plus the post-rebase blank-line drift in the two markdown-audit SKILL.md files. Run `mdsmith fix .` on the rebased tree to regenerate every catalog so the merged content stays in sync. Also reverts the temporary mdsmith CI debug instrumentation added in 685b228 / 121e7e0 (the PR-comment fix-diff capture that flagged the stale catalogs). The Copilot review on PR #288 correctly noted the wider permissions and the mutate-and- post pattern were unrelated to plan 156, so back to the original `./mdsmith check .` invocation.
121e7e0 to
28abcaa
Compare
|  | ||
| <?/build?> | ||
| ``` | ||
|
|
There was a problem hiding this comment.
metainfo should be added at the end of a rule readme.
ensure the kind's schema enforces this. metainfo should be rendered by injecting the variables from the frontmatter.
There was a problem hiding this comment.
Tracked as plan 169 (plan/169_rule-readme-meta-information-sync.md, pushed in 5141bc6).
Scoping this out of #288: enforcing Meta-Information as terminal means dropping the trailing ## ... wildcard in internal/rules/proto.md and revalidating all ~54 rule READMEs, and the frontmatter {field} body-sync is — per docs/guides/schemas.md — explicitly an unwired follow-up on MDS020's legacy file-schema parser. Plan 169 captures both halves with acceptance tests so #288 stays focused on plan 156 composition. Leaving this thread open until 169 lands.
Generated by Claude Code
dce8a1d to
2803cb1
Compare
Copilot review (PR #288): when a single config layer sets both a non-empty `schema:` and a non-empty `inline-schema:`, extractSchemaSourceFromSettings returned at the `schema` arm and TranslateLayerSettings then stripped both keys, silently dropping the inline source. The rule's rejectDualSchemaSettings guard in ApplySettings was bypassed because translation removed the keys before ApplySettings ran, and top-level cfg.Rules / overrides / convention presets are not covered by validateKindSchemaSources. TranslateLayerSettings now detects a dual-source layer (hasDualSchemaSource, mirroring rejectDualSchemaSettings' non-empty semantics) and passes the layer through untouched, so the keys survive deep-merge and the existing guard still surfaces the original "cannot set both" config error. Cross-layer composition is unaffected — the check only fires when one map carries both. Added regression tests; new code is 100% line and branch covered.
Adds the composition engine so a file resolved by multiple kinds can get the union of every kind's required-structure schema instead of the last one winning. - internal/schema.Compose merges frontmatter (CUE conjunction for shared keys), sections (merge by heading label; `## ...` slots, bare `?`, preamble stay distinct), Closed (stricter wins), Matcher cardinality (required-by-any wins), Filename (first non-empty; conflicts error), CrossReferences/Acronyms/Index (acronyms: document-wide scope wins). Built against the plan-156 #295 unified `heading:` discriminator model. - The config merge layer accumulates each layer's `schema:` / `inline-schema:` into an append-mode `schema-sources` list via the new rule.SettingsTranslator interface, so internal/config carries no rule-name special case (mirrors rule.ListMerger). - MDS020 loads every source, composes them, validates the composed schema; single-source keeps the legacy file/inline paths; multi-source Fix writes the composed Index side-output. - docs: schemas guide gains a "Composition across kinds" worked example; cross-system doc records the contract. Deferred: wiring directive-rule-readme to compose on top of rule-readme. main now keeps Meta-Information last with Pattern before it (#295/#302); appending directive-proto.md's Pattern after rule-readme's Meta-Information would order it wrong. That schema restructuring (review comment 2) is tracked separately; .mdsmith.yml and directive-proto.md stay on main's standalone directive-rule-readme schema for now. Full suite, lint, and mdsmith check green.
codecov/changes flagged 8 uncovered lines in internal/schema/ compose.go — the schema-model port's mergeMatcher and cloneContent helpers. Add targeted same-package unit tests for: a/b nil matcher arms, min/max widening (required-wins, wider-max, optional-both, bounded+unbounded), Sequential OR, and the Columns deep-copy branch. compose.go is now 100% line and branch covered (gobco clean); full suite, lint, and mdsmith check green.
Copilot review (PR #288): when a single config layer sets both a non-empty `schema:` and a non-empty `inline-schema:`, extractSchemaSourceFromSettings returned at the `schema` arm and TranslateLayerSettings then stripped both keys, silently dropping the inline source. The rule's rejectDualSchemaSettings guard in ApplySettings was bypassed because translation removed the keys before ApplySettings ran, and top-level cfg.Rules / overrides / convention presets are not covered by validateKindSchemaSources. TranslateLayerSettings now detects a dual-source layer (hasDualSchemaSource, mirroring rejectDualSchemaSettings' non-empty semantics) and passes the layer through untouched, so the keys survive deep-merge and the existing guard still surfaces the original "cannot set both" config error. Cross-layer composition is unaffected — the check only fires when one map carries both. Added regression tests; new code is 100% line and branch covered.
69421a0 to
ba407fc
Compare
isLikelyArchetypeName, extractSchemaSourceFromSettings, and EffectiveKinds were only exercised indirectly via call sites, leaving codecov/changes flagging per-file coverage drift. Add table-driven unit tests that hit every return path directly so the three functions report 100% line and branch coverage. https://claude.ai/code/session_01C4XwUp4AkhzqjrvSSHMMZS
mergeMatcher widened the run-length max (took the larger of the two maxima), which broke the composition contract "every input's constraint holds": composing 1..3 with 5..10 silently yielded 5..10, dropping the ..3 cap. Make cardinality a true intersection — min is the larger bound, max is the smaller (0 = unbounded), and disjoint ranges return a composition error, mirroring how conflicting filename patterns surface. Thread the error through mergeScopes and composeSectionLists; flatten the latter behind a section accumulator so the added error paths stay within the complexity budget. Also relocate the misplaced TestApplySettings_SchemaSourcesList doc comment to its function so it no longer stacks above an unrelated test. https://claude.ai/code/session_01C4XwUp4AkhzqjrvSSHMMZS
PR #288 review raised that Meta-Information must be the terminal section and its bullets should render from frontmatter. The rule-readme schema permits later sections and MDS020's file-schema path still uses the legacy parser, so frontmatter body-sync is unwired. Scope that out of the plan-156 PR into its own plan rather than expanding #288's blast radius across all rule READMEs. https://claude.ai/code/session_01C4XwUp4AkhzqjrvSSHMMZS
…ssue refs Lines like " #22 \"Mandatory headings\"" and " #288.**" are GitHub issue/PR references soft-wrapped inside list items, not malformed ATX headings. Computing `after` before the MD023 check and returning nil when after[0] is a digit avoids flagging these lines. Add TestCheck_IssueReference, TestCheck_IssueReferenceAtColumn1, and TestFix_IssueReferenceUnchanged to pin the behaviour. https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz
The previous guard returned nil for any '#'+digit line regardless of indentation. That silenced MD018 for genuine malformed headings like #1Heading or ##22Title at column 1. Scope the guard to leading>0: a '#'+digit run on an indented line is almost certainly a soft-wrapped issue/PR reference (#22, #288) — at column 1 it is a missing-space defect and is flagged normally. Add TestCheck_DigitAtColumn1IsFlagged and TestFix_DigitAtColumn1 to pin the column-1 behaviour and prevent regression. https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz
* feat: add MDS059 atx-heading-whitespace rule Implements rule MDS059 covering the markdownlint ATX-heading whitespace family (MD018–MD021) and heading indentation (MD023). Detects missing space, multiple spaces, closed ATX markers, and leading indentation on raw ATX heading lines. Autofix normalizes every defect to open ATX with a single space and no leading indent. Skips fenced/indented code blocks and directive bodies. Closes plan 176. https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz * test(MDS059): add coverage tests for internal helpers Three statement-coverage gaps in rule.go — the all-hash guard in checkClosingATX, the level-out-of-range guard in normalizeLine, and the empty-string early return in extractContent — are only reachable by calling the unexported functions directly. Add rule_coverage_test.go to hit all three paths and reach 100% statement coverage. https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz * fix(MDS059): address Copilot review — CRLF, tab, C# false positive - Add \r to TrimRight calls so CRLF line endings never produce spurious "missing space after # in heading" diagnostics on empty ATX headings (##\r\n). - Flag a tab immediately after opening hashes as "missing space" and normalise it to a single space (#\tHeading → # Heading). - Only treat a trailing # run as a closing ATX marker when preceded by whitespace (CommonMark rule). Previously, content like "# C#" was wrongly diagnosed and mangled by Fix. The case with no preceding space now returns nil cleanly. Consequence: #Heading# is fixed only for MD018 (missing opening space) → # Heading#; MD020 coverage downgraded to partial in the coverage matrix. - Update rule README to reflect accurate behaviour. https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz * fix(MDS059): address second Copilot review - Coverage matrix: correct preamble to 41 of 52 (2 partial), 11 remaining; remove completed plan 176 from plan list. - normalizeLine: preserve trailing \r when the original line has one so CRLF files don't get mixed LF/CRLF after a partial fix. Test: TestFix_PreservesCRLFOnRewrittenLines. - Add TestCheck_SkipsPIBlock and TestFix_PreservesPIBlock to pin the directive-body (PI block) skip behaviour for Check and Fix. https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz * fix(test): drain interleaved notifications in lspPipe.shutdown shutdown() used request(), which reads exactly one frame. After a rename the server emits publishDiagnostics notifications that can arrive before the shutdown response, causing the id==99 assertion to see nil. Switch to requestPickResult(), which already loops past interleaved server frames, matching the pattern used by every other multi-step LSP test helper. https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz * docs(plan 176): clarify CommonMark interpretation for #Heading# case Split the acceptance criterion that covered both `# Heading #` and `#Heading#` into separate bullets. The `#Heading#` bullet now explicitly notes that per CommonMark a trailing `#` without preceding whitespace is content, not a closing marker, so MD020 is partial for that case. This matches the implementation and resolves the Copilot review comment about the checked-off criterion contradicting the fix output. https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz * fix(MDS064): rename message to "multiple spaces or tabs after # in heading" leadingSpaces() counts tabs as well as spaces, so the check at line 71 fires for mixed whitespace like "# \tHeading" (space then tab). The old message "multiple spaces after # in heading" was inaccurate in that case. Rename to "multiple spaces or tabs after # in heading" and add tests for the space+tab pattern. https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz * fix(MDS064): rename package headingwhitespace → atxheadingwhitespace; register in all.go The package name headingwhitespace did not follow the repo convention of deriving the directory name from the rule name with hyphens removed (blockquote-whitespace → blockquotewhitespace, list-marker-space → listmarkerspace). Rename to atxheadingwhitespace for consistency. Also fixes a production bug: the package was never blank-imported in internal/rules/all/all.go, so MDS064 was not registered and would never run in cmd/mdsmith. Add the import in alphabetical order. Also updates plan/176 front matter and design text to replace the provisional MDS060 ID with the shipped MDS064. https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz * fix(MDS064): skip '#' followed by digit to avoid false positives on issue refs Lines like " #22 \"Mandatory headings\"" and " #288.**" are GitHub issue/PR references soft-wrapped inside list items, not malformed ATX headings. Computing `after` before the MD023 check and returning nil when after[0] is a digit avoids flagging these lines. Add TestCheck_IssueReference, TestCheck_IssueReferenceAtColumn1, and TestFix_IssueReferenceUnchanged to pin the behaviour. https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz * fix(lint): sort atxheadingwhitespace import into alphabetical position The import was left at the position where headingwhitespace used to sit (after headingstyle) instead of its correct alphabetical slot after ambiguousemphasis. Moves it so gofmt/goimports is satisfied. https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz * docs(MDS064): clarify space requirement only applies when heading has content The README said the opening hashes must be followed by "exactly one space" without qualification, implying empty headings like "##" are invalid. Reword to make clear the space check only applies when the heading has content; empty headings are valid and produce no diagnostic. https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz * fix(MDS064): narrow digit guard to indented lines only The previous guard returned nil for any '#'+digit line regardless of indentation. That silenced MD018 for genuine malformed headings like #1Heading or ##22Title at column 1. Scope the guard to leading>0: a '#'+digit run on an indented line is almost certainly a soft-wrapped issue/PR reference (#22, #288) — at column 1 it is a missing-space defect and is flagged normally. Add TestCheck_DigitAtColumn1IsFlagged and TestFix_DigitAtColumn1 to pin the column-1 behaviour and prevent regression. https://claude.ai/code/session_01KCANC7X1jkYcLdAUqmBeaz --------- Co-authored-by: Claude <noreply@anthropic.com>
Summary
Implements plan 156: when a file resolves to multiple kinds that each declare a
required-structureschema, the schemas now compose instead of the last one winning. The merge layer accumulates schema sources into aschema-sourceslist, and the rule loads and composes them at check time.Key Changes
Core composition logic (
internal/schema/compose.go, new):Compose()merges multiple schemas by unioning frontmatter keys (with CUE conjunction&for shared keys), merging sections by literal heading text, OR-ing theclosed:flag, and picking the first non-emptyrequire.filenamepatterncompose_test.govalidates all composition rulesRule refactoring (
internal/rules/requiredstructure/rule.go):SchemaSourcestruct holds either a file path or pre-parsed inline schemaRule.Sourcesfield (canonical) replaces single-source logic;SchemaandInlineSchemanow mirror the first source for backward compatibilityApplySettings()refactored into focused helpers (applySchemaSetting,applyInlineSchemaSetting,applySchemaSourcesSetting)parseSchemaSources()parses theschema-sourceslist installed by the merge layerreflectSingleSource()keeps legacy fields in sync when exactly one source is configuredSettingMergeMode()returnsMergeAppendforschema-sourcesso layers accumulateMerge layer (
internal/config/merge.go):translateSchemaSource()converts legacyschema:andinline-schema:settings into single-entryschema-sourceslistseffectiveRules()applies translation to all layers (defaults, convention, user, kinds, overrides)clearSchemaState()and related "last source wins" logic; sources now accumulateSettingMergeMode()updated to appendschema-sourcesacross layersCheck-time composition (
internal/rules/requiredstructure/rule.go):Check()loads all sources and callsschema.Compose()to merge them before validationlint.File.RootFSConfig tests (
internal/config/schema_kinds_test.go):TestEffectiveInjectsInlineSchemato verifyschema-sourceslist structureTestEffectiveClearsPriorSchemaWhenNewSourceArrivestoTestEffectiveComposesSchemaSourcesAcrossKinds; now verifies both sources accumulate instead of the last winningRule tests (
internal/rules/requiredstructure/compose_test.go, new):TestApplySettings_SchemaSourcesListvalidates multi-source parsingTestCheck_ComposedSchemasRequireBothSets(acceptance criterion Add heading-max, code-block-max, and stern mode to line-length rule #1): file resolving to two kinds with disjoint required sections must require bothTestCheck_ComposedSchemasComposeFrontmatter(acceptance criterion [WIP] Add heading-max, code-block-max, and stern mode to line-length rule #2): disjoint frontmatter keys composeDocumentation and examples:
docs/guides/schemas.mdwith composition rules and worked example (directive-rule-readme + rule-readme)docs/development/architecture/cross-system.mdwith schema composition contractinternal/rules/directive-proto.mdto declare only Pattern-specific constraints; common rule-README structure now comes fromproto.mdvia composition.mdsmith.ymlto assign all four directive READMEs to bothrule-readmeanddirective-rule-readmekindsNotable Implementation Details
https://claude.ai/code/session_01C4XwUp4AkhzqjrvSSHMMZS